﻿using CG5_KT.Primitives;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;

namespace CG5_KT.Scene
{
    public class Engine
    {
        public enum ShadingMode
        {
            Flat,
            Gouraud,
            Phong
        }

        public ShadingMode Shading { get; set; }

        private byte[] backBuffer;
        private readonly float[] depthBuffer;
        private Bitmap bmp;

        private readonly int renderWidth;
        private readonly int renderHeight;

        public Engine(Bitmap bmp)
        {
            this.Shading = ShadingMode.Gouraud;
            this.bmp = bmp;
            this.renderWidth = bmp.Width;
            this.renderHeight = bmp.Height;

            // The back buffer size is equal to the number of pixels to draw
            // on screen (width*height) * 4 (R,G,B & Alpha values).
            this.backBuffer = new byte[renderWidth * renderHeight * 4];

            // To check if pixel to be drawn is closer to the camera than the one
            // already drawn. Otherwise, do not draw it.
            this.depthBuffer = new float[renderWidth * renderHeight];
        }

        public void Clear(byte r, byte g, byte b, byte a)
        {
            // Clear the Back Buffer.
            for (var index = 0; index < backBuffer.Length; index += 4)
            {
                // BGRA is used.
                backBuffer[index] = b;
                backBuffer[index + 1] = g;
                backBuffer[index + 2] = r;
                backBuffer[index + 3] = a;
            }

            // Clear the Depth Buffer.
            for (var index = 0; index < depthBuffer.Length; index++)
            {
                depthBuffer[index] = float.MaxValue;
            }
        }

        // Called to put a pixel on screen at a specific X,Y coordinates
        public void PutPixel(int x, int y, float z, Color4 color)
        {
            // As we have a 1-D Array for our back buffer
            // we need to know the equivalent cell in 1-D based
            // on the 2D coordinates on screen
            var index = (x + y * renderWidth);
            var index4 = index * 4;

            if (depthBuffer[index] < z)
            {
                return; // Discard
            }

            depthBuffer[index] = z;

            backBuffer[index4] = (byte)(color.Blue * 255);
            backBuffer[index4 + 1] = (byte)(color.Green * 255);
            backBuffer[index4 + 2] = (byte)(color.Red * 255);
            backBuffer[index4 + 3] = (byte)(color.Alpha * 255);
        }

        // Project takes some 3D coordinates and transform them
        // in 2D coordinates using the transformation matrix
        // It also transform the same coordinates and the norma to the vertex 
        // in the 3D world
        public Vertex Project(Vertex vertex, Matrix transMat, Matrix world)
        {
            // transforming the coordinates into 2D space
            var point2d = Vector3.TransformCoordinate(vertex.Coordinates, transMat);
            // transforming the coordinates & the normal to the vertex in the 3D world
            var point3dWorld = Vector3.TransformCoordinate(vertex.Coordinates, world);
            var normal3dWorld = Vector3.TransformCoordinate(vertex.Normal, world);

            // The transformed coordinates will be based on coordinate system
            // starting on the center of the screen. But drawing on screen normally starts
            // from top left. We then need to transform them again to have x:0, y:0 on top left.
            var x = point2d.X * renderWidth + renderWidth / 2.0f;
            var y = -point2d.Y * renderHeight + renderHeight / 2.0f;

            return new Vertex
            {
                Coordinates = new Vector3(x, y, point2d.Z),
                Normal = normal3dWorld,
                WorldCoordinates = point3dWorld
            };
        }

        // DrawPoint calls PutPixel but does the clipping operation before
        public void DrawPoint(Vector3 point, Color4 color) //, bool thick = false)
        {
            // Clipping what's visible on screen
            if (point.X >= 0 && point.Y >= 0 && point.X < renderWidth && point.Y < renderHeight)
            {
                // Drawing a point
                PutPixel((int)point.X, (int)point.Y, point.Z, color);

                /*if (thick == true)
                {
                    PutPixel((int)point.X + 1, (int)point.Y, point.Z, color);
                    PutPixel((int)point.X - 1, (int)point.Y, point.Z, color);

                    PutPixel((int)point.X, (int)point.Y + 1, point.Z, color);
                    PutPixel((int)point.X, (int)point.Y - 1, point.Z, color);

                    PutPixel((int)point.X + 1, (int)point.Y + 1, point.Z, color);
                    PutPixel((int)point.X - 1, (int)point.Y + 1, point.Z, color);

                    PutPixel((int)point.X + 1, (int)point.Y - 1, point.Z, color);
                    PutPixel((int)point.X - 1, (int)point.Y - 1, point.Z, color);
                }*/
            }
        }

        // drawing line between 2 points from left to right
        // papb -> pcpd
        // pa, pb, pc, pd must then be sorted before
        void ProcessScanLine(ScanLineData data, Vertex va, Vertex vb, Vertex vc, Vertex vd, Color4 color)
        {
            Vector3 pa = va.Coordinates;
            Vector3 pb = vb.Coordinates;
            Vector3 pc = vc.Coordinates;
            Vector3 pd = vd.Coordinates;

            // Thanks to current Y, we can compute the gradient to compute others values like
            // the starting X (sx) and ending X (ex) to draw between
            // if pa.Y == pb.Y or pc.Y == pd.Y, gradient is forced to 1
            var gradient1 = pa.Y != pb.Y ? (data.currentY - pa.Y) / (pb.Y - pa.Y) : 1;
            var gradient2 = pc.Y != pd.Y ? (data.currentY - pc.Y) / (pd.Y - pc.Y) : 1;

            int sx = (int)Interpolate(pa.X, pb.X, gradient1);
            int ex = (int)Interpolate(pc.X, pd.X, gradient2);

            if (sx > ex)
            {
                int temp = sx;
                sx = ex;
                ex = temp;
            }

            // starting Z & ending Z
            float z1 = Interpolate(pa.Z, pb.Z, gradient1);
            float z2 = Interpolate(pc.Z, pd.Z, gradient2);

            var snl = Interpolate(data.ndotla, data.ndotlb, gradient1);
            var enl = Interpolate(data.ndotlc, data.ndotld, gradient2);

            // drawing a line from left (sx) to right (ex) 
            // for (var x = sx; x <= ex; x++) // My
            for (var x = sx; x < ex; x++)
            {
                float gradient = (x - sx) / (float)(ex - sx);

                var z = Interpolate(z1, z2, gradient);
                var ndotl = Interpolate(snl, enl, gradient);
                // changing the color value using the cosine of the angle
                // between the light vector and the normal vector
                Color4 colorToUse = color * ndotl;
                colorToUse.Alpha = 1;
                DrawPoint(new Vector3(x, data.currentY, z), colorToUse);
            }
        }

        // drawing line between 2 points from left to right
        // papb -> pcpd
        // pa, pb, pc, pd must then be sorted before
        void ProcessScanLineFlat(ScanLineData data, Vertex va, Vertex vb, Vertex vc, Vertex vd, Color4 color)
        {
            Vector3 pa = va.Coordinates;
            Vector3 pb = vb.Coordinates;
            Vector3 pc = vc.Coordinates;
            Vector3 pd = vd.Coordinates;

            // Thanks to current Y, we can compute the gradient to compute others values like
            // the starting X (sx) and ending X (ex) to draw between
            // if pa.Y == pb.Y or pc.Y == pd.Y, gradient is forced to 1
            var gradient1 = pa.Y != pb.Y ? (data.currentY - pa.Y) / (pb.Y - pa.Y) : 1;
            var gradient2 = pc.Y != pd.Y ? (data.currentY - pc.Y) / (pd.Y - pc.Y) : 1;

            int sx = (int)Interpolate(pa.X, pb.X, gradient1);
            int ex = (int)Interpolate(pc.X, pd.X, gradient2);

            if (sx > ex)
            {
                int temp = sx;
                sx = ex;
                ex = temp;
            }

            // starting Z & ending Z
            float z1 = Interpolate(pa.Z, pb.Z, gradient1);
            float z2 = Interpolate(pc.Z, pd.Z, gradient2);

            // drawing a line from left (sx) to right (ex) 
            for (var x = sx; x < ex; x++)
            {
                float gradient = (x - sx) / (float)(ex - sx);

                var z = Interpolate(z1, z2, gradient);
                var ndotl = data.ndotla;
                // changing the color value using the cosine of the angle
                // between the light vector and the normal vector
                Color4 colorToUse = color * ndotl;
                colorToUse.Alpha = 1;
                DrawPoint(new Vector3(x, data.currentY, z), colorToUse);
            }
        }

        public void Present(Graphics gr)
        {
            // Create a BitmapData and Lock all pixels to be written.
            BitmapData bmpData = bmp.LockBits(
                new Rectangle(0, 0, bmp.Width, bmp.Height),
                ImageLockMode.ReadWrite, bmp.PixelFormat);

            // Copy the data from the byte array into BitmapData.Scan0
            Marshal.Copy(backBuffer, 0, bmpData.Scan0, backBuffer.Length);

            // Unlock the pixels.
            bmp.UnlockBits(bmpData);

            // Once everything is ready, display the bitmap.
            gr.DrawImage(this.bmp, new Point(0, 0));
        }

        // Clamping values to keep them between min and max.
        float Clamp(float value, float min, float max)
        {
            return Math.Max(min, Math.Min(value, max));
        }

        // Clamping values to keep them between 0 and 1
        float Clamp(float value)
        {
            float min = 0;
            float max = 1;

            return Clamp(value, min, max);
        }

        // Interpolating the value between 2 vertices 
        // min is the starting point, max the ending point
        // and gradient the % between the 2 points.
        float Interpolate(float min, float max, float gradient)
        {
            return min + ((max - min) * Clamp(gradient));
        }

        // Compute the cosine of the angle between the light vector and the normal vector
        // Returns a value between 0 and 1
        float ComputeNDotL(Vector3 vertex, Vector3 normal, Vector3 lightPosition)
        {
            var lightDirection = vertex - lightPosition;

            normal.Normalize();
            lightDirection.Normalize();

            return Math.Max(0, Vector3.Dot(normal, lightDirection));
        }

        public void DrawTriangle(Vertex v1, Vertex v2, Vertex v3, Color4 color)
        {
            // Sorting the points in order to always have this order on screen p1, p2 & p3
            // with p1 always up (thus having the Y the lowest possible to be near the top screen)
            // then p2 between p1 & p3
            if (v1.Coordinates.Y > v2.Coordinates.Y)
            {
                var temp = v2;
                v2 = v1;
                v1 = temp;
            }

            if (v2.Coordinates.Y > v3.Coordinates.Y)
            {
                var temp = v2;
                v2 = v3;
                v3 = temp;
            }

            if (v1.Coordinates.Y > v2.Coordinates.Y)
            {
                var temp = v2;
                v2 = v1;
                v1 = temp;
            }

            Vector3 p1 = v1.Coordinates;
            Vector3 p2 = v2.Coordinates;
            Vector3 p3 = v3.Coordinates;

            // Light position 
            Vector3 lightPos = new Vector3(0, -2, 0);
            // computing the cos of the angle between the light vector and the normal vector
            // it will return a value between 0 and 1 that will be used as the intensity of the color
            float nl1 = ComputeNDotL(v1.WorldCoordinates, v1.Normal, lightPos);
            float nl2 = ComputeNDotL(v2.WorldCoordinates, v2.Normal, lightPos);
            float nl3 = ComputeNDotL(v3.WorldCoordinates, v3.Normal, lightPos);

            var data = new ScanLineData { };

            // computing lines' directions
            float dP1P2, dP1P3;

            // http://en.wikipedia.org/wiki/Slope
            // Computing slopes
            if (p2.Y - p1.Y > 0)
                dP1P2 = (p2.X - p1.X) / (p2.Y - p1.Y);
            else
                dP1P2 = 0;

            if (p3.Y - p1.Y > 0)
                dP1P3 = (p3.X - p1.X) / (p3.Y - p1.Y);
            else
                dP1P3 = 0;

            if (dP1P2 > dP1P3)
            {
                for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
                {
                    data.currentY = y;

                    if (y < p2.Y)
                    {
                        data.ndotla = nl1;
                        data.ndotlb = nl3;
                        data.ndotlc = nl1;
                        data.ndotld = nl2;
                        ProcessScanLine(data, v1, v3, v1, v2, color);
                    }
                    else
                    {
                        data.ndotla = nl1;
                        data.ndotlb = nl3;
                        data.ndotlc = nl2;
                        data.ndotld = nl3;
                        ProcessScanLine(data, v1, v3, v2, v3, color);
                    }
                }
            }
            else
            {
                for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
                {
                    data.currentY = y;

                    if (y < p2.Y)
                    {
                        data.ndotla = nl1;
                        data.ndotlb = nl2;
                        data.ndotlc = nl1;
                        data.ndotld = nl3;
                        ProcessScanLine(data, v1, v2, v1, v3, color);
                    }
                    else
                    {
                        data.ndotla = nl2;
                        data.ndotlb = nl3;
                        data.ndotlc = nl1;
                        data.ndotld = nl3;
                        ProcessScanLine(data, v2, v3, v1, v3, color);
                    }
                }
            }
        }

        public void DrawTriangleFlat(Vertex v1, Vertex v2, Vertex v3, Color4 color)
        {
            // Sorting the points in order to always have this order on screen p1, p2 & p3
            // with p1 always up (thus having the Y the lowest possible to be near the top screen)
            // then p2 between p1 & p3.
            if (v1.Coordinates.Y > v2.Coordinates.Y)
            {
                var temp = v2;
                v2 = v1;
                v1 = temp;
            }

            if (v2.Coordinates.Y > v3.Coordinates.Y)
            {
                var temp = v2;
                v2 = v3;
                v3 = temp;
            }

            if (v1.Coordinates.Y > v2.Coordinates.Y)
            {
                var temp = v2;
                v2 = v1;
                v1 = temp;
            }

            Vector3 p1 = v1.Coordinates;
            Vector3 p2 = v2.Coordinates;
            Vector3 p3 = v3.Coordinates;

            // Normal face's vector is the average normal between each vertex's normal
            // computing also the center point of the face.
            Vector3 vnFace = (v1.Normal + v2.Normal + v3.Normal) / 3;
            Vector3 centerPoint = (v1.WorldCoordinates + v2.WorldCoordinates + v3.WorldCoordinates) / 3;

            // Light position.
            Vector3 lightPos = new Vector3(0, -1, 0);

            // Computing the cos of the angle between the light vector and the normal vector
            // it will return a value between 0 and 1 that will be used as the intensity of the color.
            float ndotl = ComputeNDotL(centerPoint, vnFace, lightPos);

            var data = new ScanLineData { ndotla = ndotl };

            // computing lines' directions
            float dP1P2, dP1P3;

            // http://en.wikipedia.org/wiki/Slope
            // Computing slopes
            if (p2.Y - p1.Y > 0)
                dP1P2 = (p2.X - p1.X) / (p2.Y - p1.Y);
            else
                dP1P2 = 0;

            if (p3.Y - p1.Y > 0)
                dP1P3 = (p3.X - p1.X) / (p3.Y - p1.Y);
            else
                dP1P3 = 0;

            // First case where triangles are like that:
            // P1
            // -
            // -- 
            // - -
            // -  -
            // -   - P2
            // -  -
            // - -
            // -
            // P3
            if (dP1P2 > dP1P3)
            {
                for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
                {
                    data.currentY = y;

                    if (y < p2.Y)
                    {
                        ProcessScanLineFlat(data, v1, v3, v1, v2, color);
                    }
                    else
                    {
                        ProcessScanLineFlat(data, v1, v3, v2, v3, color);
                    }
                }
            }
            // First case where triangles are like that:
            //       P1
            //        -
            //       -- 
            //      - -
            //     -  -
            // P2 -   - 
            //     -  -
            //      - -
            //        -
            //       P3
            else
            {
                for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
                {
                    data.currentY = y;

                    if (y < p2.Y)
                    {
                        ProcessScanLineFlat(data, v1, v2, v1, v3, color);
                    }
                    else
                    {
                        ProcessScanLineFlat(data, v2, v3, v1, v3, color);
                    }
                }
            }
        }

        // The main method of the engine that re-computes each vertex projection
        // during each frame.
        public void Render(Camera camera, List<Model> models, Route route)
        {
            var viewMatrix = Matrix.MakeViewMatrix(camera.Position, camera.Target, Vector3.UnitY);
            var projectionMatrix = Matrix.MakePerspectiveMatrix(0.78f,
                                                           (float)renderWidth / renderHeight,
                                                           0.1f, 1.0f);

            // Draw route.
            route.Draw(this, viewMatrix * projectionMatrix);
            
            // Draw models.
            foreach (var model in models)
            {
                foreach (var mesh in model.Meshes)
                {
                    // Beware to apply rotation before translation.
                    var worldMatrix = Matrix.RotationYawPitchRoll(mesh.Rotation.Y, mesh.Rotation.X, mesh.Rotation.Z) *
                                      Matrix.Translation(mesh.Position);

                    var transformMatrix = worldMatrix * viewMatrix * projectionMatrix;

                    // Draw only vertices.
                    /*foreach (var vertex in mesh.Vertices)
                    {
                        // First, we project the 3D coordinates into the 2D space
                        var point = Project(vertex, transformMatrix, worldMatrix);
                        // Then we can draw on screen
                        DrawPoint(point.Coordinates, new Color4(128));
                    }
                    return;*/

                    var faceIndex = 0;
                    foreach (var face in mesh.Faces)
                    {
                        var vertexA = mesh.Vertices[face.A];
                        var vertexB = mesh.Vertices[face.B];
                        var vertexC = mesh.Vertices[face.C];

                        // Back face culling.
                        var faceNormal = Vector3.Cross(
                                            (vertexB.Coordinates - vertexA.Coordinates),
                                            (vertexC.Coordinates - vertexA.Coordinates));
                        var cameraToTriangeDirection = vertexA.Coordinates - camera.Position;

                        if (Vector3.Dot(cameraToTriangeDirection, faceNormal) >= 0)
                        {
                            continue;
                        }

                        var pixelA = Project(vertexA, transformMatrix, worldMatrix);
                        var pixelB = Project(vertexB, transformMatrix, worldMatrix);
                        var pixelC = Project(vertexC, transformMatrix, worldMatrix);

                        if (this.Shading == ShadingMode.Flat)
                        {
                            DrawTriangleFlat(pixelA, pixelB, pixelC, mesh.SurfaceColor);
                        }
                        else if (this.Shading == ShadingMode.Gouraud)
                        {
                            DrawTriangle(pixelA, pixelB, pixelC, mesh.SurfaceColor);
                        }
                        
                        faceIndex++;
                    }
                }
            }
        }
    }
}
